筆記目錄

Skip to content

RCSI 簡介與改良版 Entity Framework 鎖定提示攔截器

TLDR

  • RCSI (Read Committed Snapshot Isolation) 是目前解決 SQL Server 讀寫鎖定衝突的最佳實踐,建議優先於 WITH (NOLOCK) 使用。
  • RCSI 為資料庫層級設定,開啟後可避免讀取被寫入阻塞,且不會讀到髒資料。
  • 若需在 EF Core 中精細控制鎖定提示(如 NOLOCK),建議使用 TagWith 搭配 DbCommandInterceptor 進行顯式宣告,而非全域強制替換。
  • 針對參數嗅探 (Parameter Sniffing) 問題,建議優先考慮 SQL Server 的 Query Store 機制。
  • 框架層級的全域處理應以「降低團隊認知負荷」為核心目標,並根據專案的風險容忍度進行權衡。

RCSI 是什麼?

RCSI (Read Committed Snapshot Isolation) 透過「版本控制」解決鎖定問題。當資料正在寫入時,讀取操作不會被阻塞,也不會讀到未提交的髒資料,而是讀取寫入前的最後一個快照版本。

RCSI 的運作特性

  • 資料庫層級設定:開啟後(ALTER DATABASE ... SET READ_COMMITTED_SNAPSHOT ON),全域生效,所有未指定隔離層級的查詢都會自動進入快照模式。
  • 無法局部開啟:若需針對特定查詢進行精細控制,應改用 Snapshot Isolation 並在 Transaction 中顯式指定。
  • 不取代樂觀鎖:RCSI 僅解決查詢阻塞問題,若要處理資料更新時的併發衝突,仍須搭配 RowVersion 機制。

RCSI vs. NOLOCK vs. READPAST

  • WITH (NOLOCK):允許讀取髒資料,風險最高。
  • WITH (READPAST):遇到鎖定直接跳過,適用於 Queue 處理,不適用於報表。
  • RCSI:讀取一致的快照資料,無需加 Hint 即可達成非阻塞讀取。

TIP

在現代環境(SSD 普及、雲端預設開啟)下,RCSI 的 TempDB 負擔已不再是致命傷,是比到處加 NOLOCK 更乾淨的解法。


TagWith + Interceptor 改良實作

在無法全面開啟 RCSI 或有特殊需求的情境下,可透過 TagWith 進行顯式宣告,僅在特定查詢介入鎖定提示。

1. 定義擴充方法

透過 Fluent API 語法,讓程式碼意圖更清晰:

csharp
public static class EfHintExtensions {
    public const string TagNoLock = "SQL_HINT: NOLOCK";
    public const string TagReadPast = "SQL_HINT: READPAST";
    public const string TagOptionUnknown = "SQL_OPTION: OPTIMIZE FOR UNKNOWN";
    public const string TagRecompile = "SQL_OPTION: RECOMPILE";

    public static IQueryable<T> WithNoLock<T>(this IQueryable<T> query) => query.TagWith(TagNoLock);
    public static IQueryable<T> WithReadPast<T>(this IQueryable<T> query) => query.TagWith(TagReadPast);
    public static IQueryable<T> WithOptionUnknown<T>(this IQueryable<T> query) => query.TagWith(TagOptionUnknown);
    public static IQueryable<T> WithRecompile<T>(this IQueryable<T> query) => query.TagWith(TagRecompile);
}

2. 實作攔截器 (Interceptor)

此攔截器支援 Schema 格式(如 [dbo].[Table])並自動合併 SQL Option,避免語法錯誤。

csharp
public class SqlTaggingInterceptor : DbCommandInterceptor {
    private static readonly RegexOptions regexOptions = RegexOptions.Multiline | RegexOptions.IgnoreCase;

    private static readonly Regex tableAliasRegex = new(
        @"(?<tableAlias>(?:FROM|JOIN)\s+(?:\[[^\]]+\]\.)?\[[^\]]+\]\s+AS\s+\[[^\]]+\])(?!\s+WITH\s*\()",
        regexOptions
    );

    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result
    ) {
        FixCommand(command);
        return base.ReaderExecuting(command, eventData, result);
    }

    private static void FixCommand(DbCommand command) {
        if (string.IsNullOrWhiteSpace(command.CommandText)) return;
        string text = command.CommandText;
        bool isChanged = false;

        string hintToApply = null;
        if (text.Contains(EfHintExtensions.TagNoLock)) hintToApply = "WITH (NOLOCK)";
        else if (text.Contains(EfHintExtensions.TagReadPast)) hintToApply = "WITH (READPAST)";

        if (hintToApply != null) {
            text = tableAliasRegex.Replace(text, $"${{tableAlias}} {hintToApply}");
            isChanged = true;
        }

        List<string> options = new ();
        if (text.Contains(EfHintExtensions.TagOptionUnknown)) options.Add("OPTIMIZE FOR UNKNOWN");
        if (text.Contains(EfHintExtensions.TagRecompile)) options.Add("RECOMPILE");

        if (options.Count > 0) {
            text = text.TrimEnd().TrimEnd(';');
            text += $" OPTION ({string.Join(", ", options)});";
            isChanged = true;
        }

        if (isChanged) command.CommandText = text;
    }
}

實際使用範例

csharp
// 報表:允許髒讀 + 解決參數嗅探
List<Order> orders = context.Orders
    .WithNoLock()
    .WithOptionUnknown()
    .ToList();

// Queue:跳過鎖定行
Job job = context.Jobs
    .WithReadPast()
    .FirstOrDefault();

關於全域攔截與隱形機制的思辨

在評估是否採用全域處理(如自動注入 AsNoTracking 或攔截器)時,建議從以下三個維度進行權衡:

  • 情境頻率:若 90% 的查詢都屬於同一類型(如報表系統),全域預設處理能大幅簡化開發。
  • 辨別難度:若「高風險操作」比「低風險操作」更容易識別,則預設開啟全域處理反而能降低誤判風險。
  • 風險容忍度:需評估誤判造成的後果(如資料不一致 vs. 查詢變慢),並選擇最符合專案需求的架構。

結論:技術手段的選擇應以「降低團隊認知負荷」為優先,而非盲目遵循教條式的 Anti-Pattern 定義。


異動歷程

  • 2026-02-05 初版文件建立。